package in.uncod.android.bypass;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.ClickableSpan;
import android.text.style.ImageSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.QuoteSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.text.style.URLSpan;
import android.util.DisplayMetrics;
import android.util.Patterns;
import android.util.TypedValue;
import android.view.View;
import in.uncod.android.bypass.Element.Type;
import in.uncod.android.bypass.style.HorizontalLineSpan;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Bypass {
static {
System.loadLibrary("bypass");
}
private final Options mOptions;
private final int mListItemIndent;
private final int mBlockQuoteIndent;
private final int mCodeBlockIndent;
private final int mHruleSize;
private final int mHruleTopBottomPadding;
// Keeps track of the ordered list number for each LIST element.
// We need to track multiple ordered lists at once because of nesting.
private final Map<Element, Integer> mOrderedListNumber = new ConcurrentHashMap<>();
private ImageSpanClickListener mImageSpanClickListener;
public Bypass(@NonNull Context context) {
this(context, new Options());
}
public Bypass(@NonNull Context context, @NonNull Options options) {
mOptions = options;
DisplayMetrics dm = context.getResources().getDisplayMetrics();
mListItemIndent = (int) TypedValue.applyDimension(mOptions.mListItemIndentUnit,
mOptions.mListItemIndentSize, dm);
mBlockQuoteIndent = (int) TypedValue.applyDimension(mOptions.mBlockQuoteIndentUnit,
mOptions.mBlockQuoteIndentSize, dm);
mCodeBlockIndent = (int) TypedValue.applyDimension(mOptions.mCodeBlockIndentUnit,
mOptions.mCodeBlockIndentSize, dm);
mHruleSize = (int) TypedValue.applyDimension(mOptions.mHruleUnit,
mOptions.mHruleSize, dm);
mHruleTopBottomPadding = (int) dm.density * 10;
}
public void setImageSpanClickListener(ImageSpanClickListener listener) {
mImageSpanClickListener = listener;
}
public CharSequence markdownToSpannable(@NonNull String markdown) {
return markdownToSpannable(markdown, null);
}
public CharSequence markdownToSpannable(@NonNull String markdown, @Nullable ImageGetter imageGetter) {
Document document = processMarkdown(markdown);
int size = document.getElementCount();
CharSequence[] spans = new CharSequence[size];
for (int i = 0; i < size; i++) {
spans[i] = recurseElement(document.getElement(i), i, size, imageGetter);
}
return TextUtils.concat(spans);
}
private native Document processMarkdown(String markdown);
// The 'numberOfSiblings' parameters refers to the number of siblings within the parent, including
// the 'element' parameter, as in "How many siblings are you?" rather than "How many siblings do
// you have?".
private CharSequence recurseElement(Element element, int indexWithinParent, int numberOfSiblings,
ImageGetter imageGetter) {
Type type = element.getType();
boolean isOrderedList = false;
if (type == Type.LIST) {
String flagsStr = element.getAttribute("flags");
if (flagsStr != null) {
int flags = Integer.parseInt(flagsStr);
isOrderedList = (flags & Element.F_LIST_ORDERED) != 0;
if (isOrderedList) {
mOrderedListNumber.put(element, 1);
}
}
}
int size = element.size();
CharSequence[] spans = new CharSequence[size];
for (int i = 0; i < size; i++) {
spans[i] = recurseElement(element.children[i], i, size, imageGetter);
}
// Clean up after we're done
if (isOrderedList) {
mOrderedListNumber.remove(this);
}
CharSequence concat = TextUtils.concat(spans);
SpannableStringBuilder builder = new ReverseSpannableStringBuilder();
String text = element.getText();
if (element.size() == 0
&& element.getParent() != null
&& element.getParent().getType() != Type.BLOCK_CODE) {
text = text.replace('\n', ' ');
}
// Retrieve the image now so we know whether we're going to have something to display later
// If we don't, then show the alt text instead (if available).
Drawable imageDrawable = null;
String imageLink = element.getAttribute("link");
if (type == Type.IMAGE && imageGetter != null && !TextUtils.isEmpty(imageLink)) {
imageDrawable = imageGetter.getDrawable(imageLink);
}
switch (type) {
case LIST:
if (element.getParent() != null
&& element.getParent().getType() == Type.LIST_ITEM) {
builder.append("\n");
}
break;
case LINEBREAK:
builder.append("\n");
break;
case LIST_ITEM:
builder.append(" ");
if (mOrderedListNumber.containsKey(element.getParent())) {
int number = mOrderedListNumber.get(element.getParent());
builder.append(Integer.toString(number) + ".");
mOrderedListNumber.put(element.getParent(), number + 1);
} else {
builder.append(mOptions.mUnorderedListItem);
}
builder.append(" ");
break;
case AUTOLINK:
builder.append(element.getAttribute("link"));
break;
case HRULE:
// This ultimately gets drawn over by the line span, but
// we need something here or the span isn't even drawn.
builder.append("-");
break;
case IMAGE:
// Display alt text (or title text) if there is no image
if (imageDrawable == null) {
String show = element.getAttribute("alt");
if (TextUtils.isEmpty(show)) {
show = element.getAttribute("title");
}
if (!TextUtils.isEmpty(show)) {
show = "[" + show + "]";
builder.append(show);
}
} else {
// Character to be replaced
builder.append("\uFFFC");
}
break;
}
builder.append(text);
builder.append(concat);
// Don't auto-append whitespace after last item in document. The 'numberOfSiblings'
// is the number of children the parent of the current element has (including the
// element itself), hence subtracting a number from that count gives us the index
// of the last child within the parent.
if (element.getParent() != null || indexWithinParent < (numberOfSiblings - 1)) {
if (type == Type.LIST_ITEM) {
if (element.size() == 0 || !element.children[element.size() - 1].isBlockElement()) {
builder.append("\n");
}
} else if (element.isBlockElement() && type != Type.BLOCK_QUOTE) {
if (type == Type.LIST) {
// If this is a nested list, don't include newlines
if (element.getParent() == null || element.getParent().getType() != Type.LIST_ITEM) {
builder.append("\n");
}
} else if (element.getParent() != null
&& element.getParent().getType() == Type.LIST_ITEM) {
// List items should never double-space their entries
builder.append("\n");
} else {
builder.append("\n\n");
}
}
}
switch (type) {
case HEADER:
String levelStr = element.getAttribute("level");
int level = Integer.parseInt(levelStr);
setSpan(builder, new RelativeSizeSpan(mOptions.mHeaderSizes[level - 1]));
setSpan(builder, new StyleSpan(Typeface.BOLD));
break;
case LIST:
setBlockSpan(builder, new LeadingMarginSpan.Standard(mListItemIndent));
break;
case EMPHASIS:
setSpan(builder, new StyleSpan(Typeface.ITALIC));
break;
case DOUBLE_EMPHASIS:
setSpan(builder, new StyleSpan(Typeface.BOLD));
break;
case TRIPLE_EMPHASIS:
setSpan(builder, new StyleSpan(Typeface.BOLD_ITALIC));
break;
case BLOCK_CODE:
setSpan(builder, new LeadingMarginSpan.Standard(mCodeBlockIndent));
setSpan(builder, new TypefaceSpan("monospace"));
break;
case CODE_SPAN:
setSpan(builder, new TypefaceSpan("monospace"));
break;
case LINK:
case AUTOLINK:
String link = element.getAttribute("link");
if (!TextUtils.isEmpty(link) && Patterns.EMAIL_ADDRESS.matcher(link).matches()) {
link = "mailto:" + link;
}
setSpan(builder, new URLSpan(link));
break;
case BLOCK_QUOTE:
// We add two leading margin spans so that when the order is reversed,
// the QuoteSpan will always be in the same spot.
setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent));
setBlockSpan(builder, new QuoteSpan(mOptions.mBlockQuoteColor));
setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent));
setBlockSpan(builder, new StyleSpan(Typeface.ITALIC));
break;
case STRIKETHROUGH:
setSpan(builder, new StrikethroughSpan());
break;
case HRULE:
setSpan(builder, new HorizontalLineSpan(mOptions.mHruleColor, mHruleSize, mHruleTopBottomPadding));
break;
case IMAGE:
if (imageDrawable != null) {
setClickableImageSpan(builder, new ImageSpan(imageDrawable), imageLink);
}
break;
}
return builder;
}
private static void setSpan(SpannableStringBuilder builder, Object what) {
builder.setSpan(what, 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
// These have trailing newlines that we want to avoid spanning
private static void setBlockSpan(SpannableStringBuilder builder, Object what) {
int length = Math.max(0, builder.length() - 1);
builder.setSpan(what, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
private void setClickableImageSpan(final SpannableStringBuilder builder, final ImageSpan what,
final String link) {
builder.setSpan(what, 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
if (mImageSpanClickListener != null) {
mImageSpanClickListener.onImageClicked(widget, what, link);
}
//http://stackoverflow.com/questions/5595785/highlight-on-clickablespan-click
widget.invalidate();
}
}, 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
/**
* Configurable options for how Bypass renders certain elements.
*/
public static final class Options {
private float[] mHeaderSizes;
private String mUnorderedListItem;
private int mListItemIndentUnit;
private float mListItemIndentSize;
private int mBlockQuoteColor;
private int mBlockQuoteIndentUnit;
private float mBlockQuoteIndentSize;
private int mCodeBlockIndentUnit;
private float mCodeBlockIndentSize;
private int mHruleColor;
private int mHruleUnit;
private float mHruleSize;
public Options() {
mHeaderSizes = new float[]{
1.5f, // h1
1.4f, // h2
1.3f, // h3
1.2f, // h4
1.1f, // h5
1.0f, // h6
};
mUnorderedListItem = "\u2022";
mListItemIndentUnit = TypedValue.COMPLEX_UNIT_DIP;
mListItemIndentSize = 10;
mBlockQuoteColor = 0xff0000ff;
mBlockQuoteIndentUnit = TypedValue.COMPLEX_UNIT_DIP;
mBlockQuoteIndentSize = 10;
mCodeBlockIndentUnit = TypedValue.COMPLEX_UNIT_DIP;
mCodeBlockIndentSize = 10;
mHruleColor = Color.GRAY;
mHruleUnit = TypedValue.COMPLEX_UNIT_DIP;
mHruleSize = 1;
}
public Options setHeaderSizes(float[] headerSizes) {
if (headerSizes == null) {
throw new IllegalArgumentException("headerSizes must not be null");
} else if (headerSizes.length != 6) {
throw new IllegalArgumentException("headerSizes must have 6 elements (h1 through h6)");
}
mHeaderSizes = headerSizes;
return this;
}
public Options setUnorderedListItem(String unorderedListItem) {
mUnorderedListItem = unorderedListItem;
return this;
}
public Options setListItemIndentSize(int unit, float size) {
mListItemIndentUnit = unit;
mListItemIndentSize = size;
return this;
}
public Options setBlockQuoteColor(int color) {
mBlockQuoteColor = color;
return this;
}
public Options setBlockQuoteIndentSize(int unit, float size) {
mBlockQuoteIndentUnit = unit;
mBlockQuoteIndentSize = size;
return this;
}
public Options setCodeBlockIndentSize(int unit, float size) {
mCodeBlockIndentUnit = unit;
mCodeBlockIndentSize = size;
return this;
}
public Options setHruleColor(int color) {
mHruleColor = color;
return this;
}
public Options setHruleSize(int unit, float size) {
mHruleUnit = unit;
mHruleSize = size;
return this;
}
}
/**
* Retrieves images for markdown images.
*/
public interface ImageGetter {
/**
* This method is called when the parser encounters an image tag.
*
* @param source the source url
* @return the drawable
*/
Drawable getDrawable(String source);
}
}